부동소수점 오차를 금액 계산에서 피하는 방법

부동소수점 오차를 금액 계산에서 피하는 방법

JavaScript에서 0.1과 0.2를 더한 결과가 정확히 0.3이 아닌 것은 언어의 버그가 아니다. 많은 소수를 이진 부동소수점으로 유한하게 표현할 수 없기 때문이다. 화면 좌표에서는 작은 오차일 수 있지만 금액 정산에서는 누적될 수 있다.

한눈에 보기

  • Number는 IEEE 754 배정밀도 부동소수점이다.
  • 금액은 가능한 한 최소 화폐 단위의 정수로 계산한다.
  • 반올림 시점과 정책을 도메인 규칙으로 명시한다.

목차

오차가 생기는 이유

console.log(0.1 + 0.2);       // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false

십진수 0.1을 이진수로 정확히 끝내서 표현할 수 없어 가장 가까운 값으로 저장한다.

최소 단위 정수로 계산하기

원 단위처럼 소수점이 없다면 처음부터 정수를 사용한다. 달러라면 센트 단위로 바꾸어 계산한다.

const priceInCents = 1999;
const quantity = 3;
const totalInCents = priceInCents * quantity;

const display = (totalInCents / 100).toFixed(2);

매우 큰 금액이나 정밀도가 더 필요한 경우 BigInt 또는 decimal 라이브러리를 검토한다.

반올림 정책을 한곳에 두기

할인 후 세금을 계산할지, 세금 후 할인을 계산할지에 따라 결과가 달라진다. 각 항목에서 반올림할지 최종 합계에서 한 번만 할지도 결정해야 한다.

표현과 계산을 분리한다

toFixed는 문자열 표시를 위한 함수다. 중간 계산의 정밀도 정책을 대신하지 않는다.

곱한 뒤 반올림하면 언제 충분할까

센트처럼 소수 자릿수가 고정된 입력을 애플리케이션 경계에서 정수로 바꾸는 방식은 실용적이다.

function parseMoney(input, fractionDigits = 2) {
  const normalized = input.trim();
  if (!/^\d+(\.\d+)?$/.test(normalized)) {
    throw new Error("Invalid money format");
  }

  const [whole, fraction = ""] = normalized.split(".");
  const padded = fraction.padEnd(fractionDigits, "0");

  if (padded.length > fractionDigits) {
    throw new Error("Too many fraction digits");
  }

  return Number(whole) * 10 ** fractionDigits + Number(padded);
}

console.log(parseMoney("19.99")); // 1999

Number("19.99") * 100을 반올림하는 짧은 방법도 있지만 문자열에서 자릿수를 직접 검증하면 입력 정책이 더 분명하다. 매우 큰 값에서는 Number의 안전한 정수 범위를 넘을 수 있으므로 BigInt나 decimal 타입을 고려한다.

할인과 세금 순서가 결과를 바꾼다

금액 버그는 부동소수점만의 문제가 아니다. 업무 규칙의 계산 순서와 반올림 시점이 더 자주 문제를 만든다.

function roundHalfUp(numerator, denominator) {
  return Math.floor((numerator + denominator / 2) / denominator);
}

function calculateLine({ unitPrice, quantity, discountPercent, taxPercent }) {
  const subtotal = unitPrice * quantity;
  const discounted = roundHalfUp(
    subtotal * (100 - discountPercent),
    100,
  );
  const tax = roundHalfUp(discounted * taxPercent, 100);

  return {
    subtotal,
    discount: subtotal - discounted,
    tax,
    total: discounted + tax,
  };
}

위 예시는 정수 최소 단위를 사용하고 “할인 후 세금, 항목 단위 반올림”이라는 정책을 코드에 드러낸다. 실제 세무 규칙은 국가와 사업 정책에 따라 다르므로 그대로 사용하면 안 된다.

결정해야 할 항목 가능한 선택
할인 순서 세전 할인 / 세후 할인
반올림 단위 항목별 / 주문 전체
반올림 방식 half-up / half-even / 버림 / 올림
통화 자릿수 0자리 / 2자리 / 3자리 등
환율 적용 시점 승인 / 매입 / 정산 시점
화면 포맷과 계산 규칙을 섞지 않는다

Intl.NumberFormat은 사용자에게 통화 형식으로 보여주는 도구다. 내부 계산 단위와 반올림 정책을 대신하지 않는다.

Decimal 타입이 필요한 경우

비율이 여러 번 누적되거나 금융 계산처럼 자릿수와 rounding mode가 계약인 경우 decimal 라이브러리 또는 DB의 DECIMAL 타입이 적합하다.

// 특정 라이브러리 API가 아니라 개념을 보여주는 의사 코드
const amount = Decimal.from("19.99");
const rate = Decimal.from("0.075");
const tax = amount.multiply(rate).round(2, "HALF_UP");

DB에서 DECIMAL(12, 2)로 저장해도 Node.js 드라이버가 문자열로 반환할 수 있다. 이를 무심코 Number로 바꾸면 다시 부동소수점과 안전 정수 범위 문제가 생긴다.

const row = await database.query("SELECT total FROM invoices WHERE id = ?", [id]);
const totalText = row.total; // 예: "1234567890.12"

애플리케이션, DB, API JSON에서 금액을 어떤 타입과 단위로 표현할지 끝까지 일치시켜야 한다.

테스트는 경계값을 중심으로

describe("calculateLine", () => {
  test.each([
    { unitPrice: 1, quantity: 1, discountPercent: 0, taxPercent: 0 },
    { unitPrice: 1999, quantity: 3, discountPercent: 10, taxPercent: 8 },
    { unitPrice: 999999, quantity: 100, discountPercent: 33, taxPercent: 10 },
  ])("preserves integer money units: %#", (input) => {
    const result = calculateLine(input);
    expect(Number.isInteger(result.total)).toBe(true);
  });
});

정확히 반 단위가 되는 값, 0원, 최대 금액, 환불 음수값, 서로 다른 통화를 섞는 입력을 별도 사례로 둔다.

결론

금액 계산의 핵심은 오차를 숨기는 것이 아니라 단위와 반올림 시점을 명시하는 것이다. 저장, 계산, 표시의 단위를 문서와 타입에 남긴다.

관련 노트